SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
11.6 KB · 193 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { WorldMap } from '@/components/map/world-map';5import { Block, DL, Empty, Head, Row } from '@/components/satellite/primitives';6import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';7import { Container, Stat } from '@/components/ui/section';8import { api, ApiError } from '@/lib/api';9import { fmtDate, fmtDateTime, fmtDeg, fmtInt, num, titleCase } from '@/lib/format';10import { EVENT_TYPE_LABELS, OBJECT_TYPE_LABELS, routes, SITE_URL } from '@/lib/site';11import type { LaunchDetail } from '@/lib/types';1213type Params = { params: Promise<{ cospar: string }> };14const TYPE_ORDER = ['PAYLOAD', 'STATION', 'CREWED', 'ROCKET_BODY', 'DEBRIS', 'UNKNOWN'];1516async function load(cospar: string): Promise<LaunchDetail> {17  try {18    return (await api.launch(cospar)).data;19  } catch (e) {20    if (e instanceof ApiError && e.notFound) notFound();21    throw e;22  }23}2425function describe(l: LaunchDetail): string {26  return `Launch ${l.cospar_launch_id}${l.primary_name ? ` (${l.primary_name})` : ''} on ${fmtDate(l.launch_date)}${l.site_name ? ` from ${l.site_name}` : ''}: ${fmtInt(l.payload_count)} payloads, ${fmtInt(l.object_count)} catalogued objects, ${fmtInt(l.on_orbit_count)} still on orbit. Every object with its status, orbit and owner on SatelliteIndex.`;27}2829export async function generateMetadata({ params }: Params): Promise<Metadata> {30  const { cospar } = await params;31  let l: LaunchDetail;32  try {33    l = (await api.launch(cospar)).data;34  } catch {35    return { title: 'Launch', robots: { index: false } };36  }37  const title = `${l.primary_name ?? 'Launch'} — Launch ${l.cospar_launch_id}, ${fmtDate(l.launch_date)}`;38  const description = describe(l);39  const canonical = routes.launch(l.cospar_launch_id);40  return { title, description, alternates: { canonical }, openGraph: { title, description, url: `${SITE_URL}${canonical}`, type: 'article' }, twitter: { card: 'summary', title, description } };41}4243function OwnerChips({ owners }: { owners: LaunchDetail['owners'] }) {44  return (45    <ul className="flex flex-wrap gap-1.5">46      {owners.map((o) => (47        <li key={o.code}>48          <Link href={routes.launches(`owner=${encodeURIComponent(o.code)}`)} className="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" title={`${o.kind} · all launches with owner ${o.code}`}>49            <span className="mono text-ink">{o.code}</span> {o.name}50          </Link>51        </li>52      ))}53    </ul>54  );55}5657export default async function LaunchPage({ params }: Params) {58  const { cospar } = await params;59  const l = await load(cospar);60  const objects = num(l.object_count) ?? l.objects.length;61  const onOrbit = num(l.on_orbit_count) ?? 0;62  const groups = TYPE_ORDER.map((t) => ({ type: t, rows: l.objects.filter((o) => o.object_type === t) })).filter((g) => g.rows.length);63  const hasSite = l.site_lat != null && l.site_lon != null;64  const jsonLd = { '@context': 'https://schema.org', '@type': 'Event', name: `Launch ${l.cospar_launch_id}${l.primary_name ? ` — ${l.primary_name}` : ''}`, startDate: l.launch_date, url: `${SITE_URL}${routes.launch(l.cospar_launch_id)}`, description: describe(l), location: l.site_name ? { '@type': 'Place', name: l.site_name, ...(hasSite ? { geo: { '@type': 'GeoCoordinates', latitude: l.site_lat, longitude: l.site_lon } } : {}) } : undefined, identifier: { '@type': 'PropertyValue', propertyID: 'COSPAR launch id', value: l.cospar_launch_id } };6566  return (67    <Container wide>68      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />69      <header className="pb-6 pt-6 md:pt-10">70        <p className="eyebrow mono">Launch · COSPAR {l.cospar_launch_id} · {fmtDate(l.launch_date)}</p>71        <h1 className="display mt-2 break-words text-3xl md:text-5xl">{l.primary_name ?? `Launch ${l.cospar_launch_id}`}</h1>72        <p className="mt-3 text-sm text-ink-2">73          {l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? 'Launch site unknown'}74          {l.site_country && <> · <Link href={routes.country(l.site_country)} className="link mono">{l.site_country}</Link></>}75          {hasSite && <span className="mono ml-2 text-xs text-ink-3">{l.site_lat!.toFixed(2)}°, {l.site_lon!.toFixed(2)}°</span>}76        </p>77      </header>7879      <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">80        <div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-4">81          <Stat label="Payloads" value={fmtInt(l.payload_count)} />82          <Stat label="Catalogued objects" value={fmtInt(objects)} hint="payloads + rocket bodies + debris" />83          <Stat label="Still on orbit" value={fmtInt(onOrbit)} accent={onOrbit > 0} />84          <Stat label="Decayed" value={fmtInt(objects - onOrbit)} hint={objects ? `${((1 - onOrbit / objects) * 100).toFixed(0)}% of objects` : undefined} />85          <div className="col-span-2 sm:col-span-4">86            <p className="eyebrow mb-2">Owners (SATCAT owner codes)</p>87            {l.owners.length ? (88              <>89                <OwnerChips owners={l.owners.slice(0, 12)} />90                {l.owners.length > 12 && (91                  <details className="mt-2">92                    <summary className="inline-flex min-h-9 cursor-pointer items-center text-xs text-accent hover:underline">Show {fmtInt(l.owners.length - 12)} more owners</summary>93                    <div className="mt-2"><OwnerChips owners={l.owners.slice(12)} /></div>94                  </details>95                )}96              </>97            ) : (98              <Empty>No owner codes recorded for the objects of this launch.</Empty>99            )}100          </div>101        </div>102        <div className="overflow-hidden rounded-lg border border-rule">103          {hasSite ? (104            <WorldMap markers={[{ lat: l.site_lat!, lon: l.site_lon!, color: 'var(--accent)', size: 6, pulse: true, label: l.site_name ?? undefined, href: l.site_slug ? routes.launchSite(l.site_slug) : undefined }]} title={`Launch site: ${l.site_name ?? 'unknown'}`} />105          ) : (106            <p className="p-6 text-center text-sm text-ink-3">Launch site coordinates unavailable</p>107          )}108        </div>109      </div>110111      <div className="mt-6 divide-y divide-[color:var(--rule)]">112        <Block id="objects">113          <Head eyebrow="Catalogue" title={<>Objects from this launch <span className="tnum text-ink-3">· {fmtInt(l.objects.length)}{objects > l.objects.length ? ` of ${fmtInt(objects)}` : ''}</span></>} action={{ href: routes.satellites(`launch=${encodeURIComponent(l.cospar_launch_id)}`), label: 'Open in explorer' }} />114          {groups.length === 0 && <Empty>No catalogued object is linked to this launch.</Empty>}115          {groups.map((g) => {116            const collapsed = g.rows.length > 40;117            const table = (118              <div className="overflow-x-auto scrollbar-thin">119                <table className="data-table stack">120                  <thead>121                    <tr><th>Name</th><th>NORAD</th><th>COSPAR</th><th>Status</th><th>Orbit</th><th className="num">Perigee / apogee</th><th className="num">Incl.</th><th>Decayed</th><th>Operator</th></tr>122                  </thead>123                  <tbody>124                    {g.rows.map((o) => (125                      <tr key={o.id}>126                        <td data-label="Name" className="primary"><Link href={routes.satellite(o.slug)} className="link font-medium">{o.name}</Link>{o.constellation_slug && <Link href={routes.constellation(o.constellation_slug)} className="ml-2 text-2xs text-ink-3 hover:text-accent">{o.constellation_name}</Link>}</td>127                        <td data-label="NORAD" className="mono text-xs">{o.norad_id ?? '—'}</td>128                        <td data-label="COSPAR" className="mono text-xs">{o.cospar_id ?? '—'}</td>129                        <td data-label="Status"><StatusBadge status={o.status} /></td>130                        <td data-label="Orbit"><OrbitBadge orbitClass={o.orbit_class} /></td>131                        <td data-label="Perigee / apogee" className="num mono text-xs">{o.perigee_km !== null ? `${fmtInt(o.perigee_km)} / ${fmtInt(o.apogee_km)} km` : '—'}</td>132                        <td data-label="Inclination" className="num mono text-xs">{fmtDeg(o.inclination_deg)}</td>133                        <td data-label="Decayed" className="mono text-xs">{o.decay_date ? fmtDate(o.decay_date) : <span className="text-ink-3">on orbit</span>}</td>134                        <td data-label="Operator" className="text-xs">{o.operator_slug ? <Link href={routes.operator(o.operator_slug)} className="link">{o.operator_name}</Link> : o.operator_name ?? '—'}</td>135                      </tr>136                    ))}137                  </tbody>138                </table>139              </div>140            );141            const heading = <span className="inline-flex items-center gap-2 text-sm font-semibold text-ink-2"><TypeBadge type={g.type} /> {OBJECT_TYPE_LABELS[g.type] ?? g.type} <span className="tnum text-ink-3">· {fmtInt(g.rows.length)}</span></span>;142            return collapsed ? (143              <details key={g.type} className="mt-6 first:mt-0">144                <summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-md border border-rule px-3 hover:bg-plane-2">145                  {heading}146                  <span className="text-xs text-ink-3">show all</span>147                </summary>148                <div className="mt-2">{table}</div>149              </details>150            ) : (151              <div key={g.type} className="mt-6 first:mt-0">152                <p className="mb-2">{heading}</p>153                {table}154              </div>155            );156          })}157        </Block>158159        <Block id="details">160          <Head eyebrow="Record" title="Launch record" />161          <DL>162            <Row label="COSPAR launch id" value={l.cospar_launch_id} />163            <Row label="Launch date" value={fmtDate(l.launch_date)} hint="(SATCAT, UTC)" />164            <Row label="Launch site" mono={false} value={l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? '—'} />165            <Row label="Site code" value={l.site_code ?? '—'} />166            <Row label="SatelliteIndex id" value={<span className="break-all text-xs">{l.id}</span>} />167          </DL>168        </Block>169170        <Block id="events">171          <Head eyebrow="Timeline" title="Events" />172          {l.events.length ? (173            <ol className="divide-y divide-[color:var(--rule)]">174              {l.events.map((e) => (175                <li key={e.id} className="grid gap-1 py-3 sm:grid-cols-[150px_minmax(0,1fr)]">176                  <p className="mono text-xs text-ink-3">{fmtDateTime(e.event_time)}</p>177                  <div><span className="mr-2 rounded bg-plane-2 px-1.5 py-px text-[10px] uppercase tracking-wider text-ink-3">{EVENT_TYPE_LABELS[e.type] ?? titleCase(e.type)}</span><span className="text-sm">{e.title}</span>{e.summary && <p className="mt-1 text-xs text-ink-2">{e.summary}</p>}</div>178                </li>179              ))}180            </ol>181          ) : (182            <Empty>No events recorded for this launch or its objects.</Empty>183          )}184        </Block>185      </div>186187      <p className="pb-10 pt-4 text-2xs text-ink-3">188        This launch is derived from catalogued objects sharing the international designator {l.cospar_launch_id}; date and site come from SATCAT. See the <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>.189      </p>190    </Container>191  );192}193